package com.alisoft.couchdbclient;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.OutputStreamWriter;
import java.io.StringWriter;
import java.nio.charset.Charset;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.util.Assert;

import org.codehaus.jackson.map.ObjectMapper;

public class CouchDBClient {
	private static final String UTF8 = "UTF-8";
	private static Log log = LogFactory.getLog(CouchDBClient.class);
	
	private String host;
	private int port;
	private String url;
	private ObjectMapper jsonMapper = new ObjectMapper();
	/**
	 * 0 indicate timeout of infinity
	 * 
	 * timeout in ms.
	 */
	private int timeout = 10000;
	
	public CouchDBClient(String host, int port) {
		Assert.hasText(host);
		if (port <= 0) {
			throw new IllegalArgumentException("The port should be positive");
		}
		this.host = host;
		this.port = port;
		this.url = "http://" + host + ":" + port;
	}
	
	
	
	public int getTimeout() {
		return timeout;
	}



	public void setTimeout(int timeout) {
		this.timeout = timeout;
	}



	public String getHost() {
		return host;
	}



	public int getPort() {
		return port;
	}

	def withConnection(urlString, method, closure) throws IOException {
		log.debug("try to connect: ${urlString} (${method})")
		HttpURLConnection conn = null;
		try {
			URL url = new URL(urlString);
			conn = (HttpURLConnection) url.openConnection();
			conn.setConnectTimeout(timeout);
			conn.setRequestMethod(method);
			
			return closure(conn);
		} finally {
			if (conn != null) {
				conn.disconnect();
			}
		}
	}

	public List listDatabases() throws IOException {		

		return withConnection("${this.url}/_all_dbs", "GET") { conn ->		
			
			if (conn.getResponseCode() != 200) {
				// error occur
				throwException(conn, "Fail to list databases!");
				
			} else {
				return jsonMapper.readValue(new InputStreamReader(conn.getInputStream(), UTF8), ArrayList.class);	
			}			
			
		}
	}
	
	
	public void createDatabase(String databaseName) throws IOException {
		Assert.hasText(databaseName);

		withConnection("${this.url}/${databaseName}/", "PUT") { conn ->
			if (conn.getResponseCode() == 201) {
				// success
				return;
			} else if (conn.getResponseCode() == 409) {
				// already existing
				throwException(conn, "Database already exists!");
			} else {
				throwException(conn, "Fail to create database!");
			}
		}
	}

	public void deleteDatabase(String databaseName) throws IOException {
		Assert.hasText(databaseName);
		
		withConnection("${this.url}/${databaseName}/", "DELETE") { conn ->
			if (conn.getResponseCode() == 200) {
				// success
				return;
			} else if (conn.getResponseCode() == 404) {
				// already existing
				throwException(conn, "Database not found!");
			} else {
				throwException(conn, "Fail to delete database!");
			}
		} 
	}
	
	public Map getDatabaseInfo(String databaseName) throws IOException {
		Assert.hasText(databaseName);

		return withConnection("${this.url}/${databaseName}/", "GET") { conn ->
			if (conn.getResponseCode() == 200) {				
				
				return jsonMapper.readValue(new InputStreamReader(conn.getInputStream(), UTF8), HashMap.class);
			} else if (conn.getResponseCode() == 404) {
				// already existing
				return null;
			} else {
				throwException(conn, "Fail to delete database!");
			}
		} 
	}
	
	public Map getDocument(String databaseName, String docId) throws IOException {
		Assert.hasText(databaseName);
		Assert.hasText(docId);
		return getDocumentByURL("${this.url}/${databaseName}/${docId}");
	}
	
	public Map getDocument(String databaseName, String docId, String rev) throws IOException {
		Assert.hasText(databaseName);
		Assert.hasText(docId);
		Assert.hasText(rev);
		return getDocumentByURL("${this.url}/${databaseName}/${docId}?rev=${rev}");		
	}
	
	public Map getDocument(String databaseName, String docId, boolean revs, boolean revs_info) throws IOException {
		Assert.hasText(databaseName);
		Assert.hasText(docId);
		return getDocumentByURL("${this.url}/${databaseName}/${docId}?revs=${revs}&revs_info=${revs_info}");		
	}	
	
	/**
	 * Create document with given docId.
	 * If the document already exist, throw CouchDBConnectionException with responseCode 412;
	 * 
	 * If success, return {"ok": true, "id": "given_docId", "rev": "generated_rev"}
	 */
	public Map createDocument(String databaseName, String docId, String content) throws IOException {
		Assert.hasText(databaseName);
		Assert.hasText(docId);
		Assert.hasText(content);
		
		
		return doCreateDocument("${this.url}/${databaseName}/${docId}", "PUT", content);
	}
	 
	/**
	 * Create document without docId, a generated docId will be returned. 
	 * 
	 * If success, return {"ok": true, "id": "generated_id", "rev": "generated_rev"}
	 */
	public Map createDocument(String databaseName, String content) throws IOException {
		
		Assert.hasText(databaseName);
		Assert.hasText(content);
		
		return doCreateDocument("${this.url}/${databaseName}/", "POST", content);
	}
	
	 
	public Map createDocument(String databaseName, Map content) throws IOException {
		Assert.hasText(databaseName);
		Assert.notNull(content);
		
		return createDocument(databaseName, toJson(content));
	}
	
	public Map createDocument(String databaseName, String docId, Map content) throws IOException {
		Assert.hasText(databaseName);
		Assert.hasText(docId);
		Assert.notNull(content);
		
		
		return createDocument(databaseName, docId, toJson(content));
	}
	
	private String toJson(Object map) {
		StringWriter sw = new StringWriter();
		jsonMapper.writeValue(sw, map);
		return sw.toString();
	}
	
	public Map updateDocument(String databaseName, String docId, Map content) throws IOException {
		Assert.hasText(databaseName);
		Assert.hasText(docId);
		Assert.notNull(content);
		Assert.hasText(content["_rev"], "updating document shoud have _rev");
		
		return withConnection("${this.url}/${databaseName}/${docId}", "PUT") { conn ->
		
			conn.addRequestProperty("Content-Type", "application/json");
			conn.setDoOutput(true);
			OutputStreamWriter out = new OutputStreamWriter(conn.getOutputStream(), Charset.forName(UTF8));					
			
			jsonMapper.writeValue(out, content);
			
			if (conn.getResponseCode() == 201) {
				// success
				return jsonMapper.readValue(new InputStreamReader(conn.getInputStream(), UTF8), HashMap.class);
			} else {
				throwException(conn, "Fail to update document!");
			}			
		} 	
	}
	 
	public Map deleteDocument(String databaseName, String docId, String currentRev) throws IOException {
		Assert.hasText(databaseName);
		Assert.hasText(docId);
		Assert.hasText(currentRev);
		
		return withConnection("${this.url}/${databaseName}/${docId}?rev=${currentRev}", "DELETE") { conn ->
			if (conn.getResponseCode() == 200) {
				// success
				return jsonMapper.readValue(new InputStreamReader(conn.getInputStream(), UTF8), HashMap.class);
			} else {
				throwException(conn, "Fail to delete document!");
			}	
		}
	}
	
	/**
	 * http://wiki.apache.org/couchdb/HTTP_view_API
	 * 
	 */
	public Map query(String databaseName, String designDocName, String viewName, Map<String, Object> params) throws IOException {
		Assert.hasText(databaseName);
		Assert.hasText(designDocName);
		Assert.hasText(viewName);
		
		return withConnection("${this.url}/${databaseName}/_design/${designDocName}/_view/${viewName}"+buildQuery(params), "GET") { conn ->
			if (conn.getResponseCode() == 200) {
				// success
				return jsonMapper.readValue(new InputStreamReader(conn.getInputStream(), UTF8), HashMap.class);
			} else {
				throwException(conn, "Fail to query!");
			}			
		}
	}
	
	public Map getAllDocs(String databaseName, Map<String, Object> params) {
		Assert.hasText(databaseName);
		return withConnection("${this.url}/${databaseName}/_all_docs"+buildQuery(params), "GET") { conn ->
		if (conn.getResponseCode() == 200) {
			// success
			return jsonMapper.readValue(new InputStreamReader(conn.getInputStream(), UTF8), HashMap.class);
		} else {
			throwException(conn, "Fail to query!");
		}			
	}
	}
	
	private String buildQuery(Map<String, Object> params) {
		if (params == null || params.isEmpty()) {
			return "";
		}
		String ret = "?";
		boolean start = true;
		for (Map.Entry<String, Object> entry : params.entrySet()) {
		//params.each { entry ->
			if (!start) ret += "&";
			ret += "${entry.key}=${toJson(entry.value)}";
			start = false;
		}
		return ret;
	}
	
	private Map doCreateDocument(String urlString, String method, String content) throws IOException {
		
		return withConnection(urlString, method) { conn ->
		
			conn.addRequestProperty("Content-Type", "application/json");
			conn.setDoOutput(true);
			OutputStream out = conn.getOutputStream();					
			
			out.write(content.getBytes(UTF8));
			out.flush();
			out.close();
			
			if (conn.getResponseCode() == 201) {
				// success
				return jsonMapper.readValue(new InputStreamReader(conn.getInputStream(), UTF8), HashMap.class);
			} else {
				throwException(conn, "Fail to create document!");
			}			
		} 	
	}
	
	private Map getDocumentByURL(String urlString) throws IOException {
		return withConnection(urlString, "GET") { conn ->
			if (conn.getResponseCode() == 200) {						
				return jsonMapper.readValue(new InputStreamReader(conn.getInputStream(), UTF8), HashMap.class);
			} else if (conn.getResponseCode() == 404) {
				// not found
				return null;
			} else {
				throwException(conn, "Fail to get document!");
			}
		} 
	}
	
	private void throwException(HttpURLConnection conn, String errorMessage) {
		def response = jsonMapper.readValue(new InputStreamReader(conn.getErrorStream(), UTF8), HashMap.class);
		throw new CouchDBConnectionException(errorMessage, 
				response.error, response.reason, conn.getResponseCode());
	}	

}
